define(['domReady', 'd3', 'jquery', 'modernizr', 'backbone', 'underscore'], function (domReady, d3, $, Modernizr, Backbone, _) {

  // Zoom/Drag smoothing parameters.
  var SMOOTHING = {
    ENABLED: true,
    // Both the scale and translate must smooth by the same factor to maintain
    // synchronization and prevent "drift".
    FACTOR: 0.15,
    MAX_INVERT: 1.25,
  };

  function debug(msg) { $('#dbg').text(msg); }

  var Graph = function (options) {
    var width = options.width;
    var height = options.height;
    var minScale = 0.5;
    var maxScale = 8;

    _.bindAll(this, 'tickUpdate', 'getRadiusForNode');
    this.width = width;
    this.height = height;
    // Construct scaling functions.
    // this.xScale = d3.scale.linear().domain([0,width]).range([0,width/2]);
    // this.yScale = d3.scale.linear().domain([0,height]).range([0,height]);
    this.xScale = d3.scale.linear().domain([0,width]).range([0,0]);
    this.yScale = d3.scale.linear().domain([0,height]).range([0,0]);
    this.olddX = Infinity;
    this.olddY = Infinity;

    this.zoomRange = d3.scale.linear()
        .domain([minScale, maxScale])
        .rangeRound([minScale, maxScale])
        .clamp(true)
        .nice();
    var self = this;
    this.zoom = d3.behavior.zoom()
        .x(this.xScale)
        .y(this.yScale)
        .scaleExtent([minScale, maxScale])
        .on('zoom', function(e){ self.tickUpdate(e); });
    this.svgContext = d3.select('.graph')
        .append('svg')
        .attr('id', 'visualization')
        .attr('width', width)
        .attr('height', height)
        .call(this.zoom);
    this.svg = this.svgContext
        .append('g')
        .attr('id', 'elements')
        .attr('width', width)
        .attr('height', height);
    this.url = options.url;
    this.links = undefined;
    this.nodes = undefined;
    this.labels = undefined;
    this.circles = undefined;
    this.json = undefined;
    this.pointsQuadTree_ = undefined;
    this.labelClashTimerID_ = null;
    this.activeId_ = null;
    this.activeData_ = null;

    // Transform smoothing
    this.targetTranslate = [width/2, height/2];
    this.targetScale = 1.0;
    this.actualTranslate = [0,0];
    this.actualScale = this.targetScale;
    if (SMOOTHING.ENABLED) {
      setInterval(function() {
        self.smoothTransform();
      }, 1);
    }

    this.force = d3.layout.force()
      //.distance(100)
      .charge(-300)
      // .size([options.width, options.height])
      .size([width, height])
      .friction(0.4)
      .linkStrength(0.6)
         // function (o,i) {
        // o.source o.target
        // i = link id
        // return 0.5;
        // })
      .linkDistance(function (o,i) {
        // o.source o.target
        // i = link id
        // console.log(o);
        return 20 + o.source.importance + o.target.importance;
      });

    var self = this;
    d3.json(this.url, function (json) {
      // Called when loaded.
      self.json = json;

      self.setupLinks()
        .setupNodes()
        .setupLabels()
        .setuptickUpdate()
        .setupClick()
        .setupHover()
        .setupDrag();

      self.force
        .nodes(self.json.nodes)
        .links(self.json.links)
        .start();
    });

  };

  _.extend(Graph.prototype, Backbone.Events);

  Graph.prototype.getRadiusForNode = function (d) {
    var importance = d.importance || 1;
    return Math.max(5, importance * 2);
  };

  Graph.prototype.setupLinks = function () {
    this.links = this.svg.selectAll('.link')
            .data(this.json.links)
            .enter()
            .append('line')
            .attr('class', 'link');
    return this;
  };

  Graph.prototype.setupNodes = function () {
    this.nodes = this.svg.selectAll('g.node')
      .data(this.json.nodes)
      .enter()
      .append('g')
      .attr('id', function (d, i) { return 'n' + i; })
      .attr('class', 'node');
    this.nodes
      .call(this.force.drag)
      .append('circle')
      .attr('r', this.getRadiusForNode)
      .attr('class', function (d) {
        var additionalClasses = d.node_style || '';
        return 'node-circle ' + additionalClasses;
      });
    this.circles = this.svg.selectAll('g.node circle')
    return this;
  };

  Graph.prototype.setupLabels = function () {
    this.labels = this.svg.selectAll('g.label')
      .data(this.json.nodes)
      .enter()
      .append('g')
      .attr('id', function (d, i) {
        return 'l' + i;
      })
      .attr('class', function (d) {
        var additionalClasses = d.label_style || '';
        return 'label ' + additionalClasses;
      });
    // TODO(ldixon): use a SVG filter to do the drop-shadow: more robust
    // and flexible approach
    this.labels
      .append('text')
      .attr('class', function (d) {
        var additionalClasses = d.label_style || '';
        return 'label-shadow ' + additionalClasses;
      })
      .attr('text-anchor', 'middle')
      .text(function (d) { return d.name; })
    this.labels
      .append('text')
      .attr('class', function (d) {
        var additionalClasses = d.label_style || '';
        return 'label-text ' + additionalClasses;
      })
      .attr('text-anchor', "middle")
      .text(function (d) { return d.name; });
    this.updateAllLabelsShowStatus();
    return this;
  };

  Graph.prototype.setuptickUpdate = function () {
    var self = this;
    this.force.on('tick', function(e){ self.tickUpdate(e); });
    return this;
  };

  Graph.prototype.setupClick = function () {
    var self = this;
    this.svg.selectAll('g.node').on('click', function (d) {
      self.trigger('click:node', d3.event, d);
    });

    this.svg.selectAll('g.label').on('click', function (d) {
      self.trigger('click:node', d3.event, d);
    });

    return this;
  };

  Graph.prototype.hover = function (d,i,hover) {
    if ((!hover && this.activeId_ != null)
      || (hover && this.activeId_ != null && this.activeId_ != i)) {
      this.activeLabel_ = d3.select('#l' + this.activeId_);
      this.activeLabel_.classed('hidden',
        this.activeData_.showStatus == 'hidden');
      this.activeLabel_.classed('hovered-label', false);
      this.activeNode_ = d3.select('#n' + this.activeId_);
      this.activeNode_.classed('hovered-node', false);
      this.activeId_ = null;
      this.activeData_ = null;
      $.each(this.json.nodes[i].links, function(i, d) {
        d3.select(d).classed('hover', false);
      });
    }
    if (hover) {
      this.activeId_ = i;
      this.activeData_ = d;
      this.activeLabel_ = d3.select('#l' + this.activeId_);
      this.activeLabel_.classed('hidden', false);
      this.activeLabel_.classed('hovered-label', true);
      this.activeNode_ = d3.select('#n' + this.activeId_);
      this.activeNode_.classed('hovered-node', true);
      $.each(this.json.nodes[i].links, function(i, d) {
        d3.select(d).classed('hover', true);
      });
    }
  };

  Graph.prototype.setupHover = function () {
    var self = this;
    var domLinks = this.svg.selectAll('.link')[0];
    // Construct mapping from nodes to links.
    $.each(this.json.links, function(i, o) {
      var addMapping = function (node, link) {
      if (undefined == node.links) {
        node.links = [];
      }
      node.links.push(domLinks[link]);
      };
      addMapping(self.json.nodes[o.source], i);
      addMapping(self.json.nodes[o.target], i);
    });
    // this.svg.selectAll('g.label')
      // .on('mouseover', function(d,i) {
        // self.hover(d3.select(self.json.nodes[i]), i, true);
      // })
      // .on('mouseout', function(d,i) {
        // self.hover(d3.select(self.json.nodes[i]), i, false);
      // });
    this.svg.selectAll('g.node')
      .on('mouseover', function(d,i) { self.hover(d,i,true); } )
      .on('mouseout', function(d,i) { self.hover(d,i,false); } );
    return this;
  };

  // Adds some special styling for dragged nodes and their labels.
  Graph.prototype.setupDrag = function () {
    var self = this;
    var drag = this.force.drag();
      listenerFactory = function (beingDragged) {
        return function (d, i) {
          //self.hover(d,i,!beingDragged);
          var label = d3.select('#l' + i);
          //this.activeLabel_.classed('hidden', false);
          label.classed('drag-label', beingDragged);
          var node = d3.select('#n' + i);
          label.classed('drag-node', beingDragged);
          if (!beingDragged) {
            self.hover(d,i,false);
          }
        };
      };
    drag.on('dragstart.label-highlight', listenerFactory(true));
    //drag.on('drag.label-highlight', listenerFactory(true));
    drag.on('dragend.label-highlight', listenerFactory(false));
    return this;
  };

  // Given data index i, return the default visbility for it.
  Graph.prototype.getLabelDefaultShowStatus = function (i) {
    var el = d3.select('#l' + i);
    if (el.classed('always-shown-label')
      || el.classed('hovered-label')
      || el.classed('drag-label'))
      return 'visible';
    else if (el.classed('always-hidden-label'))
      return 'hidden';
    else
      return null;
  }

  Graph.prototype.resetAllLabelsShowStatus = function () {
    for (var i=0; i < this.json.nodes.length; i++) {
      this.json.nodes[i].showStatus = this.getLabelDefaultShowStatus(i);
    }
  }

  Graph.prototype.resetLabelBounds = function () {
    this.svg.selectAll('g.label')
      .each(function (d, i) {
        d.label_bounds = this.getBoundingClientRect();
      });
  }

  Graph.prototype.updateAllLabelsShowStatus = function () {
    var points = [];
    var self = this;

    clearInterval(this.labelClashTimerID_);
    this.labelClashTimerID_ = null;

    //this.resetPointsQuadTree();
    this.resetLabelBounds();
    this.resetAllLabelsShowStatus();

    // For each point without a set show status, decide if it should be
    // shown.
    for (i=0; i < this.json.nodes.length; i++) {
      var d = this.json.nodes[i];
      if (d.showStatus == null) this.setLabelShowStatus(d, i);
      //console.log("d.showStatus: " + d.showStatus);
      if (d.showStatus == 'hidden') {
        d3.select('#l' + i).classed('hidden', true);
      } else {
        d3.select('#l' + i).classed('hidden', false);
      }
    }
  };

  // Check if two boxes overlap.
  Graph.prototype.boundsOverlap = function(b1, b2) {
    return !(b1.left >= b2.right
          || b1.top >= b2.bottom
          || b1.right < b2.left
          || b1.bottom < b2.top);
  }

  // i = label index = corresponding nodes position in the nodes array.
  Graph.prototype.setLabelShowStatus = function(d, i) {
    var d2;
    var j;
    for (j=0; j < this.json.nodes.length; j++) {
      d2 = this.json.nodes[j]
      if (d2.showStatus == 'visible' &&  j != i
        && this.boundsOverlap(d.label_bounds, d2.label_bounds)) {
        d.showStatus = 'hidden';
        return;
      }
    }
    if (d.showStatus == null) {
      d.showStatus = 'visible';
    }
  }

  Graph.prototype.tickUpdate = function () {
    var self = this;
    if (this.labelClashTimerID_ == null) {
      this.labelClashTimerID_ = window.setInterval(function () {
        self.updateAllLabelsShowStatus(); }, 1000);
    }
    return this;
  };

  var GeometricZoomGraph = function (options) {
    Graph.call(this, options);
  };

  GeometricZoomGraph.prototype = Object.create(Graph.prototype);

  GeometricZoomGraph.prototype.smoothTransform = function(e) {
    // TODO(keroserene): Make the smoothing work with the strange offsets.
    function smooth(current, target, smoothFactor) {
      var delta = target - current;
      if (Math.abs(delta) < Math.abs(target * smoothFactor * 0.01))
        return target;
      return current += (delta * smoothFactor);
    }
    this.actualScale = smooth(
        this.actualScale,
        this.targetScale,
        SMOOTHING.FACTOR);
    this.actualTranslate[0] = smooth(
        this.actualTranslate[0],
        this.targetTranslate[0],
        SMOOTHING.FACTOR);
    this.actualTranslate[1] = smooth(
        this.actualTranslate[1],
        this.targetTranslate[1],
        SMOOTHING.FACTOR);
    this.svg.attr(
        'transform', 'translate(' + this.actualTranslate + ')' +
                     'scale(' + this.actualScale + ')');
    if (this.circles) {
      var invertFactor = 0.8;
      var invertScale = (invertFactor / this.actualScale) + (1.0 - invertFactor);
      invertScale = Math.min(SMOOTHING.MAX_INVERT, invertScale);
      // debug(this.actualScale + ', ' + invertScale);
      var transformVal = 'scale(' + invertScale + ')';
      this.circles.attr('transform', transformVal);
      // this.labels.attr('transform', transformVal);
        // function (d) { return 'scale(' + invertScale + ')'; }
      // );
    }
  }

  GeometricZoomGraph.prototype.tickUpdate = function (e) {
    Graph.prototype.tickUpdate.call(this, e);
    // Apply a force to keep graph on the screen for non-square view.
    // TODO(keroserene): Refresh the width and height when the context is
    // changed.
    // var k1 = 0.2;
    // this.json.nodes.forEach(function (d,i) {
      // //console.log(d);
      // var spring_border = 20;
      // if(d.x < spring_border) {
        // d.x += (spring_border - d.x) * k1;
      // }
      // if(d.x > (width - spring_border)) {
        // d.x += ((width - spring_border) - d.x) * k1;
      // }
      // if(d.y < spring_border) {
        // d.y += (spring_border - d.y) * k1;
      // }
      // if(d.y > (height - spring_border)) {
        // d.y += ((height - spring_border) - d.y) * k1;
      // }
    // });

    // Restrict scrolling/panning to the screen. Requires the dimension and
    // coordinate, which gets finnicky with svg. (ie. getBBox doesn't take into
    // account translations or scaling) so we must calculate translation borders
    // from scale.
    var translate = this.zoom.translate();
    var scale = this.zoom.scale();
    var svgElement = this.svg[0][0];
    var box = svgElement.getBBox();
    var rawCenterX = box.x + box.width / 2;
    var rawCenterY = box.y + box.height / 2;
    // Bound a translation coordinate between a minimum and maximum.
    function bound(coordinate, max, raw) {
      var tMin = 0 - raw * scale;
      var tMax = max + tMin;
      return Math.max(tMin,
             Math.min(tMax, coordinate));
    }
    var tX = bound(translate[0], this.width, rawCenterX);
    var tY = bound(translate[1], this.height, rawCenterY);
    if (SMOOTHING.ENABLED) {
      this.targetTranslate = [tX, tY];
      this.targetScale = scale;
    } else {
      translate = [tX, tY];
      this.svg.attr(
          'transform',
          'translate(' + translate + ')' +
          'scale(' + scale + ')'
      );
    }
    // this.circles.attr(
      // 'transform',
      // function (d) {
        // return 'scale(' + (1.0 / scale) + ')';
      // }
    // );

    this.nodes.attr(
      'transform',
      function (d) {
        return 'translate(' + d.x + ',' + d.y + ')';
      }
    );
    this.labels.attr(
      'transform',
      function (d) {
        if (d.short_description === undefined || d.short_description == '') {
          var voffset = (d.label_bounds.bottom - d.label_bounds.top) * 0.3;
          return 'translate(' + d.x + ',' + (d.y + voffset) + ')';
        } else {
          var voffset = Graph.prototype.getRadiusForNode(d)
            + (d.label_bounds.bottom - d.label_bounds.top) * 0.9;
          return 'translate(' + d.x + ',' + (d.y + voffset) + ')';
        }
    });

    this.links
      .attr('x1', function (d) {
        return d.source.x;
      })
      .attr('y1', function (d) {
        return d.source.y;
      })
      .attr('x2', function (d) {
        return d.target.x;
      })
      .attr('y2', function (d) {
        return d.target.y;
      });

    return this;
  };

  var Popup = function (options) {
    _.bindAll(this, 'open', 'close');

    this.$el = $('.information-panel');
    this.$heading = this.$el.find('.information-heading');
    this.$shortDescription = this.$el.find('.information-short-description');
    this.$longDescription = this.$el.find('.information-long-description');
    this.$youtube = this.$el.find('.information-youtube');
    this.$link = this.$el.find('.more-information-link');
    this.$credit = this.$el.find('.information-credit');
    this.currentCoords = {
      x: undefined,
      y: undefined
    };
    this.data = {
      heading: '',
      shortDescription: '',
      longDescription: '',
      youtube: '',
      contextUrl: '',
      credit: ''
    };
    this.panelOpen = false;
    this.transitionPrefixedName = this.hyphenisor(Modernizr
      .prefixed('transition'));
    this.transformPrefixedName = this.hyphenisor(Modernizr
      .prefixed('transform'));

    this.setupCloseEvent();
  };

  _.extend(Popup.prototype, Backbone.Events);

  Popup.prototype.hyphenisor = function (str) {
    return str.replace(/([A-Z])/g, function (str, letter) {
      return '-' + letter.toLowerCase();
    })
    .replace(/^ms-/, '-ms-');
  };

  Popup.prototype.open = function (coords) {
    var css;
    var self = this;
    if (!this.panelOpen) {
      css = {
        top: coords.y + 'px',
        left: coords.x + 'px'
      };
      css[this.transitionPrefixedName] = 'none';
      this.$el.css(css);
      setTimeout(
        function () {
          css = {
            opacity: 1,
            top: '',
            left: '50%'
          };
          css[self.transformPrefixedName] = 'scale(1)';
          css[self.transitionPrefixedName] = 'all 0.2s';
          self.$el.css(css);
        },
        0
      );
      this.panelOpen = true;
    }
    this.currentCoords = coords;
  };

  Popup.prototype.close = function () {
    var css = {
      opacity: 0,
      top: this.currentCoords.y + 'px',
      left: this.currentCoords.x + 'px'
    };
    css[this.transformPrefixedName] = 'scale(0)';
    this.$el.css(css);
    this.panelOpen = false;
  };

  Popup.prototype.setupCloseEvent = function () {
    this.$el.on('click', '.close-button', this.close);
  };

  Popup.prototype.setData = function (data) {
    this.data = data;
  };

  Popup.prototype.renderContent = function () {
    this.$heading.text(this.data.heading);
    this.$shortDescription.text(this.data.shortDescription);
    this.$longDescription.text(this.data.longDescription);
    this.$youtube.html(this.data.youtube);
    this.$credit.html(this.data.credit);
    if (this.data.contextUrl) {
      this.$link.toggle(true);
      this.$link.attr('href', this.data.contextUrl);
    } else {
      this.$link.toggle(false);
      this.$link.attr('href', '');
    }
  };

  domReady(function () {
    var graph = new GeometricZoomGraph({
        width: window.innerWidth,
        height: window.innerHeight,
        url: $('body').data('graph-url')
      }),
      popup = new Popup(),
      ESCAPE_KEY_CODE = 27;

    popup.listenTo(graph, 'click:node', function (evt, obj) {
      if (obj.short_description === undefined) {
        return;
      }

      var credit = obj.credit,
        youtube_id = obj.youtube_id,
        context_url = obj.context_url;

      popup.open({
        x: evt.offsetX,
        y: evt.offsetY - 175
      });
      if (credit.indexOf("http://") === 0) {
        credit = '<a href="' +
          credit + '" target="_blank">' + credit + '</a>';
      }
      if (credit) {
        credit = "This data entry was provided by " + credit;
      }

      if (youtube_id) {
        youtube_id =
          '<iframe title="YouTube video player" src="http://www.youtube.com/embed/' +
          youtube_id + '" width="480" height="390" frameborder="0"></iframe>';
        context_url = '';
      }

      popup.setData({
        heading: obj.name,
        shortDescription: obj.short_description,
        longDescription: obj.long_description,
        contextUrl: context_url,
        credit: credit,
        youtube: youtube_id
      });
      popup.renderContent();
    });

    $('body').on('click', function (evt) {
      if ($(evt.target).closest('.information-panel').length === 0
        && !$(evt.target).is('.circle')) {
        popup.close();
      }
    });
    $('body').on('keydown', function (evt) {
      if (evt.keyCode === ESCAPE_KEY_CODE) {
        popup.close();
      }
    });

    $('.zoom-in').on('click', graph.zoomIn);
    $('.zoom-out').on('click', graph.zoomOut);
  });
});
